Skip to content

feat: add noInterruptOnUserMessage and noContinueWhileChildrenActive options - #53

Merged
willytop8 merged 3 commits into
willytop8:mainfrom
harryzhou2000:feat/no-interrupt-user-message
Aug 6, 2026
Merged

feat: add noInterruptOnUserMessage and noContinueWhileChildrenActive options#53
willytop8 merged 3 commits into
willytop8:mainfrom
harryzhou2000:feat/no-interrupt-user-message

Conversation

@harryzhou2000

@harryzhou2000 harryzhou2000 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What changed

Adds two plugin-level options (both default false, so existing behavior is unchanged):

noInterruptOnUserMessage — when true, a new human message no longer pauses an active goal with stopReason: "user intervention":

  • the chat.message hook skips the immediate pause/abort;
  • the auto-continue claim guard no longer pauses on newHumanMessage / userInterventionDetected;
  • the idle continuation driver no longer pauses on userInterventionDetected.

The goal loop keeps running and the human message is included in the next continuation, so the message steers the loop (Codex-style) instead of stopping for /goal resume. Plugin-owned command and continuation messages were already excluded from intervention detection and remain so.

noContinueWhileChildrenActive — when true, auto-continue is deferred while the session has active child sessions (subagents, background tasks): the goal stays running, but the goal loop does not prompt the orchestrator over work a child is already doing. The gate lives in claimContinuationSource, which covers both continuation paths (normal continue and budget wrapup), and checks opencode's children + status endpoints through the existing shape adapter (children/status are replay-safe read-only operations). Hosts that cannot report children/status fail open and continue as before.

Why

For long-running autonomous goal loops, any user-role message currently stops the goal and requires an explicit /goal resume. That includes messages that are not real human steering, e.g. synthetic background-task result messages injected by some hosts, which can pause a goal seconds after it starts (observed in practice: a goal paused 55s in by a background-task completion notification). With the option enabled, such messages leave the loop untouched while real user input steers the next continuation.

In particular, OpenCode's experimental background subagents may interrupt an active goal: when a background-task result is delivered into the session as a synthetic user-role message (type: text, synthetic: true, no plugin metadata), the current chat.message path treats it as human intervention and pauses the goal. With noInterruptOnUserMessage: true those synthetic results no longer pause the loop; real human messages keep steering the next continuation instead of stopping the goal.

Conversely, while a background subagent is still running, the goal loop currently has no way to know it: the plugin's auto-continue fires on the orchestrator's own idle even though a child is doing the work, risking overlapping edits on the same files. noContinueWhileChildrenActive: true defers the continuation until the children are idle (matching the guard omo-slim applies to its own continuation nudge).

Checks run

  • CI job node-compatibility (npm run check) — passed locally on official Node 18.20.8, 20.20.2, 22.23.2, and 24.19.0 at the final commit (356/356 tests on each).
  • CI job quality-contracts:
    • npm run test:coverage — passed (Node 22)
    • npm run type:check — passed (NodeNext + Bundler)
    • npm run smoke — passed
    • Latest commit re-ran npm run check on official Node 22 — 356/356 pass (the four new tests included)
    • npm run test:mutation, npm run benchmark:behavior, npm run smoke:packed-host, npm run smoke:packed-tools, npm audit --omit=dev --audit-level=high, npm run pack:check — not completed locally (mutation alone exceeds 15 minutes on this machine and the run was interrupted); these are exercised by the GitHub pipeline once the workflow run is approved.
  • New tests (in test/goal-plugin.test.js):
    • noInterruptOnUserMessage:true keeps the goal running and steers the loop (chat.message + idle driver)
    • normalizeOptions defaults noInterruptOnUserMessage to false and keeps it boolean
    • noContinueWhileChildrenActive:true defers continuation while a child is active (defers, then continues once the child is idle)
    • active children do not block auto-continue by default
    • normalizeOptions defaults noContinueWhileChildrenActive to false and keeps it boolean
  • New tests (in test/opencode-session-api.test.js): children/status shape-adapter and replay-safety coverage.
  • Note: on the local machine's default (pixi) Node 24.18 build, test/persistence-lease.test.js and test/session-concurrency.test.js fail independently of this change (reproduced identically on pristine main); both pass on the official Node builds used by CI.

Manual OpenCode smoke testing

None against a live OpenCode host in this environment; the hook-level behavior is covered by the tests above.

Config

[
  "opencode-goal-plugin",
  {
    "noInterruptOnUserMessage": true,
    "noContinueWhileChildrenActive": true
  }
]

…ad of pausing)

When enabled, a new human message no longer pauses an active goal with
stopReason 'user intervention'. The goal loop keeps running and the
message steers the next continuation, matching Codex-style steering.
Gates the three pause sites (chat.message, auto-continue claim guard,
and the idle continuation driver); plugin-owned command/continuation
messages are never interventions either way. Default remains false.

Adds option docs to README, index.d.ts, and CHANGELOG, plus tests:
a chat.message + idle steering test and a normalizeOptions boolean test.
@harryzhou2000

harryzhou2000 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Hi @willytop8 — this PR now adds two goal-loop options (defaults unchanged): noInterruptOnUserMessage (human messages steer instead of pausing the goal) and noContinueWhileChildrenActive (defer auto-continue while subagent/background children are active). CI runs are still waiting on workflow approval (action_required) — could you approve them to start the pipeline? The full CI matrix (npm run check on Node 18/20/22/24) passes locally at the final commit (356/356), plus coverage, type contracts, and smoke. Open to review feedback on either option.

…hile subagents run)

When enabled, auto-continue is deferred while the session has active child
sessions (subagents, background tasks): the goal stays running but the goal
loop does not prompt the orchestrator over work a child is already doing.
The gate lives in claimContinuationSource, which covers both continuation
paths (normal continue and budget wrapup). It checks opencode's children
and status endpoints through the existing shape adapter (children/status are
replay-safe read-only operations); hosts that cannot report children/status
fail open and continue as before. Default remains false.

Adds adapter coverage (children/status shape probing) and behavioral tests
(deferral while a child is busy, continuation once children are idle, and
unchanged default behavior).
@harryzhou2000 harryzhou2000 changed the title feat: add noInterruptOnUserMessage option (human messages steer instead of pausing) feat: add noInterruptOnUserMessage and noContinueWhileChildrenActive options Aug 4, 2026
harryzhou2000 added a commit to harryzhou2000/cfd_solver_agentic_benchmark_workspace_template that referenced this pull request Aug 4, 2026
…age patch)

Pin the willytop8 goal-plugin fork at 03c493c (feat/no-interrupt-user-message):
a no-interrupt config option so human messages steer an active goal instead
of pausing it. PR: willytop8/OpenCode-goal-plugin#53.
The "mutating SDK calls are never replayed" mutant pins the literal

contents of REPLAY_SAFE_OPERATIONS, so widening the set to include the

read-only children/status operations left the anchor matching nothing and

the contract asserted before running the mutant. Retarget the anchor at

the current literal; the mutant is still killed, so the guarantee that a

mutating operation cannot be added to the replay-safe set is unchanged.
@harryzhou2000

Copy link
Copy Markdown
Contributor Author

Thanks for approving the workflows and for the mutation-anchor fix (0cfe9bb) — all checks are green now (Node 18/20/22/24, filesystem matrix, CodeQL, and quality contracts). The PR is mergeable as-is. Is there anything else you'd like changed before review/merge? Happy to adjust option naming, docs, or behavior.

@willytop8

Copy link
Copy Markdown
Owner

Thanks — taking this, with one fix that has to go in before noContinueWhileChildrenActive is usable.

I ran it against a live opencode 1.18.14 server, driving a real child session to busy with a local
stub provider. children returns the expected payload and the gate makes the right call for an idle
child, so the implementation is sound. But watching the event stream turned up a blocker:

t+ 2.3s  CHILD   session.status busy
t+12.6s  CHILD   session.status idle
t+12.6s  CHILD   session.idle

The parent emits nothing across the child's entire lifecycle. Since the continuation driver is
event-driven and resolves the goal from the event's own sessionID, a child's idle finds no goal and
returns — so once the gate defers, nothing ever re-drives the loop and the goal strands permanently with
stopped: false. The test in this PR fires a second parent idle event to simulate recovery, and that's
an event the real server never sends.

Fix is to remember the children a deferral was taken on and re-drive the parent when one of them goes
idle, consumed once so unrelated child sessions (the completion auditor's, in particular) can't trigger a
continuation.

Getting that right took more than I expected — the wake mechanism turned out to need a fair amount of
care around stall accounting, goal identity, and concurrency, and it went through several review rounds
before it held up. Details in the commit.

Six smaller things going in alongside it:

  • Release watched children when a goal stops while deferred, so a later child idle can't revive a dead
    loop (and the watch doesn't outlive the goal).

  • Lock in the safety valve: /goal stop, /goal pause and /goal clear all still halt the loop under
    noInterruptOnUserMessage: true. They do today — I checked — but since that's the only way to stop a
    goal once typing no longer pauses it, it should be a regression test rather than an assumption. README
    now says so at the point where it tells you to enable the option.

  • Fail open loudly on an unusable probe payload. The live SDK doesn't throw on an argument-shape
    mismatch — it resolves {error, request, response} with no data, so Array.isArray(children) is
    false and the gate silently becomes a no-op with nothing logged. Reachable today via sdkShape: "flat".

  • Record a deferred history event and status line when the gate suppresses a continuation, and
    resumed on exit, so a waiting goal is distinguishable from a hung one.

  • Treat a child as active only when the host reports a non-idle status. Presence-only is correct against
    opencode today (confirmed live — idle is a delete, not an idle entry), but the endpoint is typed to
    allow {type: "idle"}.

  • Log the children/status probe failure once per instance instead of once per turn.

noInterruptOnUserMessage I have no changes to — the reasoning in your description holds up, and it's
the right call not to key it off synthetic given the plugin identifies its own messages by nonce.

I also drove the task tool itself, to check whether its completion notification wakes the parent. On
1.18.14 that question turns out not to arise: background isn't in the task tool's exposed schema, the
call is synchronous, and the parent stays busy for the child's entire lifetime —

  t+ 4.0s  parent: busy   child: busy
  t+ 8.0s  parent: busy   child: busy
  t+12.7s  child -> idle,  parent still busy
  t+22.7s  parent -> idle

So claimContinuationSource bails at the !== "idle" check long before reaching the children gate, and
the gate never fires for task-tool children at all.

Which leaves the gate in an awkward spot: for synchronous children it's dead code, and for asynchronous
children — the experimental background subagents you're targeting, or directly-created child sessions —
it defers and then strands. I couldn't enable background subagents on 1.18.14 to test that path directly.
If you're running a build where they're available and you've watched a deferral recover, I'd genuinely
like to see it, because I couldn't produce a version where this gate does what it's meant to.

CI is green on this now. One thing I did on your branch: our mutation-contract.mjs pins the literal
contents of REPLAY_SAFE_OPERATIONS as a mutation anchor, so widening it to include children/status
— which is correct — left the anchor matching nothing and the contract failed before it could run the
mutant. That's our harness being brittle, not anything wrong with your change, so I pushed the one-line
retarget rather than sending it back to you. The mutant is still killed, so the guarantee is unchanged.
Merging now.

@willytop8
willytop8 merged commit e39defb into willytop8:main Aug 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants